Git LFSストレージ先を Google Cloud Storage にする

January 10, 2023


※ 以下内容は未検証&改善点が多く存在します。随時アップデート

実装のみ確認する場合は飛ばして

  • LFSクライアント&サーバーの仕組み

に移動

LFSサーバーサンプルソースは

はじめに

Gitで100MBを超えるファイルを扱いたい場合、一つの手段としてGit LFSを扱うこともできます。 GIt LFSについては以下の記事を参照

GitHub

https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-git-large-file-storage

GitHubでは 1GBの容量&帯域までは無料としていますがそれを超えた場合

  • 50GBの帯域+ストレージ は 5ドル/月

が発生します。 150GB借りる場合は月に15ドル

帯域にも料金が発生するため、例えば10GBのリポジトリを月に5人落とすだけで限界。

GitHub LFS料金について

https://docs.github.com/ja/billing/managing-billing-for-git-large-file-storage/about-billing-for-git-large-file-storage

Git LFSにはデータを保存するストレージ先を任意に変更できるため、Google Cloud Storageを利用します。 Google Cloud Storageの料金体系は以下 https://cloud.google.com/storage/pricing?hl=ja

  • リージョン選択は アイオナ(us-central1)
  • Standard Storage
  • 10GBを利用想定
  • 月に10人がClone想定

した場合 E43EA29254F72120251AFE2522C0D16B F22E313086672DA05275FA6ACD2B73A4

[0.020ドル(保管料)] + [0.12ドル(下り) x 10(GB) x 10(人)] = 12.2ドル 大体1500円ほどかかる想定です。(オペレーションは無視) しかし、10人クローンするのが初月だけであれば あとは保管料の 0.020ドル+α しか発生しないため GitHub 利用料金と比べたらはるかに安く済みます

※上記は皮算用のため正確な料金は計算ツールで確認してください https://cloud.google.com/products/calculator?hl=ja

また実装について以下サイト様は非常に参考になりました。LFS&LFSサーバーの仕組みを理解するのに一読をおすすめします git-lfsの仕様(サーバー側)を個人的に解説してみる https://jyn.jp/git-lfs-api/ APIGateway+Lambda+S3で格安GitLFSサーバーを運用する【使い方の紹介と車輪の再発明_:(´ཀ`」 ∠):】 https://sakataharumi.hatenablog.jp/entry/2022/09/29/223025 Git LFSをAmazon S3でいい感じにする話 https://ydkk.hateblo.jp/entry/2017/12/07/120000

※ ちなみに、AmazonS3であれば記事も豊富で上記サイトにテンプレートもあるためS3の方が楽です

AmazonS3, GoogleCloudStorage をPythonで両対応したオープンソースの giftless を利用するのもありだと思います Giftless https://giftless.datopian.com/en/latest/index.html

LFSクライアント&サーバーの仕組み

  • API構築に Google API Gateway
  • LFSサーバーに Google Function
  • LFSストレージ先に Google Cloud Storage
  • 言語は **.Net 6.0 **

を利用します

参考

詳細はLFSリポジトリを確認してください https://github.com/git-lfs/git-lfs Git LFS Batch API https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md Basic Transfers https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md

LFSクライアントの準備

公式Readmeの Installing を参考にGit LFSをインストール https://github.com/git-lfs/git-lfs#installing

リポジトリの準備

はじめに適当なリポジトリを作成 8C7F0AB57A327B6337F800263627885A

テスト用のファイルを用意 約104MBある画像を作成しました。作成した画像をLFSストレージに置くことを目指します。 ※以下参考 https://www.wakuwakubank.com/posts/400-mac-dummy-image/

BatchAPIについて

100MBのリソースをPushするとき、gitはアップロード先を教えてもらう為にLFSサーバーに問い合わせを行います。 その時以下のようなJsonが一緒に来る (必要なものだけ抜き出し)

// POST https://lfs-server.com/objects/batch
// Accept: application/vnd.git-lfs+json
// Content-Type: application/vnd.git-lfs+json
// Authorization: Basic ... (if needed)
{
  "operation": "upload", // ダウンロード or アップロードどちらの情報がほしいか
  "objects": [ // ファイルの詳細
    {
      "oid": "12345678", // LFSファイルの一意なID
      "size": 123 // LFSファイルのサイズ
    }
  ]
}

これをLFSサーバーが受けてレスポンスとして以下のようなJsonをクライアントに返せばOKです

// HTTP/1.1 200 Ok
// Content-Type: application/vnd.git-lfs+json
{
  "transfer": "basic", // なくてもいい
  "objects": [
    {
      "oid": "1111111", // リクエストで来たoidをそのまま返せばいい
      "size": 123, // リクエストできたサイズをそのまま返せばいい
      "authenticated": true, // 認証済みということにする
      "actions": {
				// ダウンロードについての情報であれば "download"
				// アップロードについての情報であれば "upload" とする
        "download": {  
          "href": "https://some-download.com", // 格納先URL
					// クライアントからストレージへの通信のheaderに何か付与したい場合書く。
					// なくてもいい
          "header": { 
            "Key": "value" 
          },
					// 以下はどちらかでOK。アクセス可能期間
					"expires_in" : 86400,
          "expires_at": "2016-11-10T15:29:07Z"
        }
      }
    }
  ],
	// なくていい。デフォルトsha256
  "hash_algo": "sha256"
}
  • Git LFS クライアント → LFSサーバー

にストレージ先を問い合わせ。そのURLを利用して

  • Git LFS クライアント → LFSストレージ

にアップロード / ダウンロードする流れ

アップロード/ダウンロードするときの処理は Basic Transfer API が担当 https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md ※流す処理をカスタマイズしなくていい場合は特にBasicから変えなくて良いです

LFSサーバー

Google Cloud コンソールから必要なものを作成していきます

プロジェクトの作成

これがないと始まらないため作成 ■プロジェクトの作成 https://cloud.google.com/resource-manager/docs/creating-managing-projects#api A13BD07B8C0ED1FB96519FBB2A933BC5 また、CLIもインストール推奨 https://cloud.google.com/sdk/docs/install-sdk

サービスアカウント作成もしておきます。 特定プロジェクトの特定権限を与えたアカウントであり、この作成したアカウントを利用してGCP内の操作を行います。 https://cloud.google.com/iam/docs/service-accounts?hl=ja

Http Cloud Functionsの作成

サーバープログラムの起動場所としてCloudFunctionsを作成 公式ページを参考に構築 ■.Netでhttp Cloud Function https://cloud.google.com/functions/docs/create-deploy-http-dotnetGoogle Cloud CLI を使用して Cloud Functions(第 1 世代)の関数を作成してデプロイする https://cloud.google.com/functions/docs/create-deploy-gcloud-1st-gen?hl=ja

※最終的にデプロイするコードは以下のGitHubに載せてあります https://github.com/Toshiki-Sakamoto/GitLFS_GoogleCloudStorage_Sample

※公式は** .Net Core 3.1 **で構築していっていますが現在であれば .Net6以上が良いので、projを開いて TargetFramework を 6.0 に変更しています 5A554DFAFAF87BD3B5F8008390CB2C53

依存関係には Funcions.Hosting に加え、CloudStorageも追加。 2DB47D7E7D07FB731B071251BA92A850

コードを変更するたびにデプロイが必要になります。

関数のデプロイ方法には

  • GoogleCloudコンソールからzipでアップロードする方法
  • gloudコマンドを利用してローカルからアップロードする方法

が取れますが、 Zipにする手間に加え、Zipでアップロードした場合 512kb を超えているとCloud Functinos からソースが見れなるためコマンドの利用をお勧めします。

(コマンド例)

gcloud functions deploy [任意Funcion名] --entry-point [エントリポイント] --runtime dotnet6 --trigger-http --allow-unauthenticated --source .

.Netのエントリポイントは [Namespace名].[クラス名]

※ コマンドでデプロイする場合 —source 引数を指定していなければ反映されない不具合(?)があります。 一見デプロイされているように見えますが実行される内容は変わっていない。ということが起きたので注意です https://stackoverflow.com/questions/47873446/how-do-i-update-google-cloud-function-source

デプロイすると Cloud Functions の一覧に表示 96507C94921484F37DF5A969833E1BC3

その他

複数のプロジェクトが存在するときの管理、切り替えについて https://cloud.google.com/sdk/docs/configurations

API Gateway

APIを定義して特定のURLでアクセスした時に Function を実行してもらいます

API Gateway と Cloud Functions のスタートガイド https://cloud.google.com/api-gateway/docs/get-started-cloud-functions?hl=ja

API定義のyamlファイルに最低限必要なものは以下のようになります

# openapi2-functions.yaml
swagger: '2.0'
info:
  title: gcs-lfs-server GCS
  description: Sample API on API Gateway with a Google Cloud Functions backend
  version: 1.0.0
schemes:
  - https
produces:
  - application/json
paths:
  /[任意名]/objects/batch: # ※1
    post:
      summary: git lfs upload and download proxy
      operationId: [任意名]
			# 実行するFunctionを定義
      x-google-backend:
        address: https://[リージョン]-[プロジェクトID].cloudfunctions.net/[実行するFunction名]
      responses:
        '200':
          description: A successful response
          schema:
            type: string

※1 の部分がURL。 LFSは **[LFSサーバーURL] の末尾に /objects/batch **をつけて呼び出すのが注意です。 LFSサーバーの任意名のみだとアクセスされません (本当はワイルドカードを利用して [任意名]+α のURLを許可するのが良さそう)

これで外部から [LFSサーバーURL]/objects/batch にアクセスした時に記述したFunctionが呼び出されます

Google Cloud Storage設定

LFSリソース保存先の設定を行います

バケットを作成します 69E3A71517DA8C2A95249A8C7A478207 作成時の設定により料金も少し変わるため設定見つつ。 今回は単体リージョンでStandardを利用しています

.Netコード構築

BatchAPIがアクセスしてきた場合、

  • ダウンロード先URL
  • アップロード先URL

を返すだけのコードを構築します

Cloud Storage のツールを使用した V4 署名プロセス https://cloud.google.com/storage/docs/access-control/signing-urls-with-helpers?hl=ja#storage-signed-url-object-csharp

大した量ではないので全部載せます ※ 現状エラー処理など怠ってます

using Google.Cloud.Functions.Framework;
using Microsoft.AspNetCore.Http;
using Google.Cloud.Storage.V1;
using System.Threading.Tasks;
using System;
using System.Net.Http;
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http.Extensions;
using System.Xml.Linq;
using System.Linq;

namespace GDriveLFS
{
    /// <summary>
    /// 署名付きURL
    /// </summary>
    public class V4SignedUrlGenerator
    {
        public string GenerateV4SignedReadUrl(
            string bucketName,
            string objectName,
            string credentialFilePath)
        {
            UrlSigner urlSigner = UrlSigner.FromServiceAccountPath(credentialFilePath);
            string url = urlSigner.Sign(bucketName, objectName, TimeSpan.FromHours(1), HttpMethod.Get);

            return url;
        }

        public string GenerateV4UploadSignedUrl(
            string bucketName,
            string objectName,
            string credentialFilePath)
        {
            UrlSigner urlSigner = UrlSigner.FromServiceAccountPath(credentialFilePath);

            var options = UrlSigner.Options.FromDuration(TimeSpan.FromHours(1));

            var template = UrlSigner.RequestTemplate
                .FromBucket(bucketName)
                .WithObjectName(objectName)
                .WithHttpMethod(HttpMethod.Put);

            string url = urlSigner.Sign(template, options);
            return url;
        }
    }

    public class GCSStorageController
    {
        public const string BacketName = "[gcsのバケット名]";
        public const string CredentialFileName = "[アカウントキー.jsonファイル名]"; // credential file (with private keys)

        private V4SignedUrlGenerator _urlGenerator = new V4SignedUrlGenerator();
        private string _credentialFilePath;

				public string Path => $"{Directory.GetCurrentDirectory()}/{CredentialFileName}";

        public void Setup()
        {
            while (!File.Exists(Path))
            {
                var path = Directory.GetParent(Directory.GetCurrentDirectory()).FullName;
                Directory.SetCurrentDirectory(path);
            }

            _credentialFilePath = Path;
        }

        public string GetDownloadURL(string objectName)
        {
            if (string.IsNullOrEmpty(_credentialFilePath)) return string.Empty;

            var result = _urlGenerator.GenerateV4SignedReadUrl(BacketName, objectName, _credentialFilePath);
            return result;
        }

        public string GetUploadURL(string objectName)
        {
            if (string.IsNullOrEmpty(_credentialFilePath)) return string.Empty;

            var result = _urlGenerator.GenerateV4UploadSignedUrl(BacketName, objectName, _credentialFilePath);
            return result;
        }
    }


    public class RequestBody
    {
        public class Object
        {
            public string oid { get; set; }
            public int size { get; set; }
        }

        public string operation { get; set; }
        public string[] transfers { get; set; }
        public Object[] objects { get; set; }
        public string hash_algo { get; set; }
    }


    public class ResponseBody
    {
        public class URL
        {
            public string href { get; set; }
            public int expires_in { get; set; }

            public static URL CreateAtDownload(string oid) =>
                new URL { href = Function.StorageController.GetDownloadURL(oid), expires_in = 86400 };

            public static URL CreateAtUpload(string oid) =>
                new URL { href = Function.StorageController.GetUploadURL(oid), expires_in = 86400 };

            public static URL CreateAtEmpty() =>
                new URL { href = "" };
        }

        public class Action
        {
            public URL upload { get; set; }
            public URL download { get; set; }

            public static Action CreateAtUpload(string oid) =>
                new Action() { upload = URL.CreateAtUpload(oid), download = URL.CreateAtEmpty() };

            public static Action CreateAtDownload(string oid) =>
                new Action() { download = URL.CreateAtDownload(oid), upload = URL.CreateAtEmpty() };
        }

        public class Object
        {
            public string oid { get; set; }
            public int size { get; set; }
            public bool authenticated { get; set; } = true;
            public Action actions { get; set; }

            public static Object CreateAtUpload(string oid, int size) =>
                new Object { oid = oid, size = size, actions = Action.CreateAtUpload(oid) };

            public static Object CreateAtDownload(string oid, int size) =>
                new Object { oid = oid, size = size, actions = Action.CreateAtDownload(oid) };
        }

        public string transfer { get; set; } = "basic";
        public Object[] objects { get; set; }


        public static ResponseBody CreateAtUpload(RequestBody.Object[] objects) =>
            new ResponseBody { objects = objects.Select(x => Object.CreateAtUpload(x.oid, x.size)).ToArray() };

        public static ResponseBody CreateAtDownload(RequestBody.Object[] objects) =>
            new ResponseBody { objects = objects.Select(x => Object.CreateAtDownload(x.oid, x.size)).ToArray() };
    }

    public class Function : IHttpFunction
    {
        public static GCSStorageController StorageController;
        private readonly ILogger _logger;

        public Function(ILogger<Function> logger) =>
            _logger = logger;

        public async Task HandleAsync(HttpContext context)
        {
            var request = context.Request;

            // If there's a body, parse it as JSON and check for "message" field.
            using TextReader reader = new StreamReader(request.Body);
            string text = await reader.ReadToEndAsync();

            if (text.Length <= 0)
            {
                _logger.LogError("RequestBody Not found");
                return;
            }

            _logger.LogInformation($"*** Request: {text}");

            try
            {
                var requestBody = JsonSerializer.Deserialize<RequestBody>(text);

                StorageController = new GCSStorageController();
								StorageController.Setup();

                var response = default(ResponseBody);

                switch (requestBody.operation)
                {
                    case "upload":
                        response = ResponseBody.CreateAtUpload(requestBody.objects);
                        break;

                    case "download":
                        response = ResponseBody.CreateAtDownload(requestBody.objects);
                        break;

                    default: // verify
                        return;
                }

                var responseBody = JsonSerializer.Serialize<ResponseBody>(response);

                _logger.LogInformation($"*** Response: {responseBody}");

                context.Response.ContentType = "application/vnd.git-lfs+json";
                await context.Response.WriteAsJsonAsync<ResponseBody>(response);
            }
            catch (JsonException parseException)
            {
                _logger.LogError(parseException, "Error parsing JSON request");
            }
        }
    }
}

特筆すべきところは UrlSigner を利用してURLを生成しているところです。 ここに指定する credentialFile の中にある認証情報を利用して発行。

権限を持ったサービスアカウントを利用して作成します。 以下参照 ■サービス アカウント キーの作成と管理 https://cloud.google.com/iam/docs/creating-managing-service-account-keys#creating_service_account_keys認証のスタートガイド https://cloud.google.com/docs/authentication/getting-started?hl=ja

※サービスアカウントキー.jsonをローカルにおいて認証するのは推奨されてません 環境変数として用意するのが良さげ (とりまということで楽だったので.. 改修します)

もしローカルにおいたjsonを参照する場合は、 VisualStudioから対象.jsonを右クリックして **クイックプロパティ > 出力ディレクトリにコピー ** をして実行時に CurrentDirectory でとれるようにしておきます。 projファイルに設定が追加されるはずです。 1AFE0B204C112879DF5136ECAF616264 (上記のような操作した後もデプロイを忘れないこと)

リポジトリのLFS設定

サーバー側の設定が終わったので、gitでLFS操作をした時クライアントからLFSサーバーに繋いでもらう必要があります

lfs公式サイトを見て設定 ■lfs https://github.com/git-lfs/git-lfs#example-usage

  • LFS化するファイルの指定

.gitattribute設定がされること。 以下は jpg, png をLFS対象にする

*.jpg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
  • LFSサーバーの指定

以下公式にある通り .config ファイルにURLが記載されているとそこの繋ぎにいきます ■Server Discovery https://github.com/git-lfs/git-lfs/blob/main/docs/api/server-discovery.md

コマンドを利用してLFSサーバーのURLを設定 git config --file=.lfsconfig lfs.url [URL(objects/batchは含めない)]

できたファイルはプロジェクトで共有するためにコミットしておきましょう 8D02F9EFD92E1F4514F0865865142F18

これで接続は問題ないため実際にPush エラーが出たら Cloud Function の ログを見て確認

Google Cloud Storage を見てファイルがコミットされていればOKです 937A851C6D1DECD9E19377C2A1A77A29